JS基础系列之

您所在的位置:网站首页 虚拟dom 实现 JS基础系列之

JS基础系列之

2024-02-16 03:04| 来源: 网络整理| 查看: 265

什么是虚拟DOM?

虚拟 DOM (Virtual DOM )这个概念相信大家都不陌生,简单地说,就是一个普通的 JavaScript 对象,包含了 tag、props、children 三个属性,以这三个属性来描述一个DOM节点,每组描述就是一个VNode,整个VNode的集合就是一个虚拟DOM树。

举个🌰

hello world!!!

将上面的HTML模版抽象成虚拟DOM树:

{ tag: 'div', props: { id: 'app' }, chidren: [ { tag: 'p', props: { className: 'text' }, chidren: [ 'hello world!!!' ] } ] } 为什么需要虚拟DOM? 具备跨平台的优势

由于虚拟DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等,是实现ssr、小程序等的基础。

提升渲染性能。

因为DOM是一个很大的对象,直接操作DOM,即便是一个空的 div 也要付出昂贵的代价,执行速度远不如我们抽象出来的 Javascript 对象的速度快,因此,把大量的DOM操作搬运到 Javascript 中,运用diff算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。虚拟DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

虚拟DOM的应用

渲染函数:cn.vuejs.org/v2/guide/re…

如何利用虚拟DOM来更新真实DOM?—— diff

diff(different),顾名思义,在构建DOM的过程中,会由diff过程就是比对计算DOM变动的地方,核心是由patch算法将变动映射到真实DOM上,所以视图的创建更新流程就是下面这样👇

用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文 档当中

当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较(diff过程),记录两棵树差异

把2所记录的差异应用到步骤1所构建的真正的DOM树上(patch),视图就更新了

Vue的diff实现

patch算法核心

function patch (oldVnode, vnode) { // some code if (sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode) } else { const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点 let parentEle = api.parentNode(oEl) // 父元素 createEle(vnode) // 根据Vnode生成新元素 if (parentEle !== null) { api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素 api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点 oldVnode = null } } // some code return vnode } // 用于比对是否是同一VNode function sameVnode (a, b) { return ( a.key === b.key && // key值 a.tag === b.tag && // 标签名 a.isComment === b.isComment && // 是否为注释节点 // 是否都定义了data,data包含一些具体信息,例如onclick , style isDef(a.data) === isDef(b.data) && sameInputType(a, b) // 当标签是的时候,type必须相同 ) }

patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点,判断两节点是否值得比较,值得比较则执行patchVnode,不值得比较则用Vnode替换oldVnode

patchVnode过程

patchVnode (oldVnode, vnode) { const el = vnode.el = oldVnode.el let i, oldCh = oldVnode.children, ch = vnode.children if (oldVnode === vnode) return if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) { api.setTextContent(el, vnode.text) }else { updateEle(el, vnode, oldVnode) if (oldCh && ch && oldCh !== ch) { updateChildren(el, oldCh, ch) }else if (ch){ createEle(vnode) //create el's children dom }else if (oldCh){ api.removeChildren(el) } } }

找到对应的真实dom,称为el

判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return

如果他们都有文本节点并且不相等,那么将Vnode的文本节点设置为el的文本节点。

如果oldVnode有子节点而Vnode没有,则删除el的子节点

如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el

如果两者都有子节点,则执行updateChildren函数比较子节点

updateChildren

这一部分可以说是 diff 算法中,变动最多的部分,因为前面的部分,各个库对比的方向基本一致,而关于子节点的对比,各个仓库都在前者基础上不断得进行改进,此处以Vue源码的 updateChildren 为例。

updateChildren (parentElm, oldCh, newCh) { let oldStartIdx = 0, newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx let idxInOld let elmToMove let before while (oldStartIdx newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }

图解updateChildren

如上图的例子,更新前是1到10排列的Node列表,更新后是乱序排列的Node列表。罗列一下图中有以下几种类型的节点变化情况:

头部相同、尾部相同的节点:如1、10

头尾相同的节点:如2、9(处理完头部相同、尾部相同节点之后)

新增的节点:11

删除的节点:8

其他节点:3、4、5、6、7

上图例子中设置了oldStart+oldEnd,newStart+newEnd这样2对指针,分别对应oldVdom和newVdom的起点和终点。Vue不断对vnode进行处理同时移动指针直到其中任意一对起点和终点相遇。处理过的节点Vue会在oldVdom和newVdom中同时将它标记为已处理。Vue通过以下措施来提升diff的性能:

1. 优先处理特殊场景

头部的同类型节点、尾部的同类型节点这类节点更新前后位置没有发生变化,所以不用移动它们对应的DOM

头尾/尾头的同类型节点这类节点位置很明确,不需要再花心思查找,直接移动DOM就好

处理了这些场景之后,一方面一些不需要做移动的DOM得到快速处理,另一方面待处理节点变少,缩小了后续操作的处理范围,性能也得到提升

2. 复用

复用是指Vue会尽可能复用DOM,尽可能不发生DOM的移动。Vue在判断更新前后指针是否指向同一个节点,其实不要求它们真实引用同一个DOM节点,实际上它仅判断指向的是否是同类节点(比如2个不同的div,在DOM上它们是不一样的,但是它们属于同类节点),如果是同类节点,那么Vue会直接复用DOM,这样的好处是不需要移动DOM

复用应该就是设置key和不设置key的区别:

不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,根据sameVnode 方法的实现可以知道key也会成为“是否是同一个节点”的判断依据。

解析updateChildren过程(设置了key)

1、处理头部的同类型节点。即oldStart和newStart指向同类节点的情况,如下图中的节点1

这种情况下,将节点1的变更更新到DOM,然后对其进行标记,标记方法是oldStart和newStart后移1位即可,过程中不需要移动DOM(更新DOM或许是要的,比如属性变更了,文本内容变更了等等)

2、处理尾部的同类型节点。即oldEnd和newEnd指向同类节点的情况,如下图中的节点10

与情况(1)类似,这种情况下,将节点10的变更更新到DOM,然后oldEnd和newEnd前移1位进行标记,同样也不需要移动DOM

3、处理头尾/尾头的同类型节点。即oldStart和newEnd,以及oldEnd和newStart指向同类节点的情况,如下图中的节点2和节点9

先看节点2,其实是往后移了,移到哪里?移到oldEnd指向的节点(即节点9)后面,移动之后标记该节点,将oldStart后移1位,newEnd前移一位

同样地,节点9接下来也是类似的处理,处理完之后成了下面这样

4、处理新增的节点

newStart来到了节点11的位置,在oldVdom中找不到节点11,说明它是新增的

那么就创建一个新的节点,插入DOM树,插到什么位置?插到oldStart指向的节点(即节点3)前面,然后将newStart后移1位标记为已处理(注意oldVdom中没有节点11,所以标记过程中它的指针不需要移动),处理之后如下图

5、处理更新的节点

经过第(4)步之后,newStart来到了节点7的位置,在oldVdom中能找到它而且不在指针位置(查找oldVdom中oldStart到oldEnd区间内的节点),说明它的位置移动了

那么需要在DOM树中移动它,移到哪里?移到oldStart指向的节点(即节点3)前面,与此同时将节点标记为已处理,跟前面几种情况有点不同,newVdom中该节点在指针处,可以移动newStart进行标记,而在oldVdom中该节点不在指针处,所以采用设置为undefined的方式来标记

6、处理3、4、5、6节点

经过第(5)步处理之后,我们看到了令人欣慰的一幕,newStart和oldStart又指向了同一个节点(即都指向节点3),很简单,按照(1)中的做法只需移动指针即可,非常高效,3、4、5、6都如此处理,处理完之后如下图

7、处理需删除的节点

经过前6步处理之后(实际上前6步是循环进行的),朋友们看newStart跨过了newEnd,它们相遇啦!而这个时候,oldStart和oldEnd还没有相遇,说明这2个指针之间的节点(包括它们指向的节点,即上图中的节点7、节点8)是此次更新中被删掉的节点。

OK,那我们在DOM树中将它们删除,再回到前面我们对节点7做了标记,为什么标记是必需的?标记的目的是告诉Vue它已经处理过了,是需要出现在新DOM中的节点,不要删除它,所以在这里只需删除节点8。

在应用中也可能会遇到oldVdom的起止点相遇了,但是newVdom的起止点没有相遇的情况,这个时候需要对newVdom中的未处理节点进行处理,这类节点属于更新中被加入的节点,需要将他们插入到DOM树中。

至此,整个diff过程结束了。整个过程是逐步找到更新前后vdom的差异,然后将差异反应到DOM树上

其他 为什么不用index作为key?

假设有这样一个列表:

delete new Vue({ name: "App", el: '#app', data() { return { arr: [1, 2, 3] }; }, methods: { handleDelete() { this.arr.splice(0, 1); } }, components: { test: { template: "{{Math.random()}}" } } })

那么初始Vnode列表是:

[ { tag: "li", key: 0, // 这里其实子组件对应的是第一个 假设子组件的text是1 }, { tag: "li", key: 1, // 这里其实子组件对应的是第二个 假设子组件的text是2 }, { tag: "li", key: 2, // 这里其实子组件对应的是第三个 假设子组件的text是3 } ];

回顾一下判断 sameNode 的时候,只会判断key、 tag、是否有data的存在(不关心内部具体的值)、是否是注释节点、是否是相同的input type,来判断是否可以复用这个节点。

当我们执行了删除操作后:

[ { tag: "li", key: 0, // 这里其实上一轮子组件对应的是第二个 假设子组件的text是2 }, { tag: "li", key: 1, // 这里其实子组件对应的是第三个 假设子组件的text是3 } ];

由于对应的 key使用了 index导致的错乱,它会把

原来的第一个节点text: 1直接复用。

原来的第二个节点text: 2直接复用。

然后发现新节点里少了一个,直接把多出来的第三个节点text: 3 丢掉。

至此为止,我们本应该把 text: 1节点删掉,然后text: 2、text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3